<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
  <title>Sky Piano</title>
  <style>
    :root {
      --bg: #0f172a;
      --card: #111827;
      --accent: #38bdf8;
      --text: #e5e7eb;
      --muted: #94a3b8;
      --key-size: 44px;
      --key-gap: 12px;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0; padding: 16px; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
      background: radial-gradient(1200px 800px at 80% -20%, #1f2937 0%, var(--bg) 40%, #020617 100%);
      color: var(--text);
    }
    .container { max-width: 960px; width: 100%; margin: 0 auto; position: relative; }
    h1 { margin: 0 0 8px; font-size: 22px; }
    .hint { margin: 0 0 20px; color: var(--muted); }
    .card {
      background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
      border: 1px solid rgba(148,163,184,0.14);
      border-radius: 14px; padding: 20px;
      box-shadow: 0 20px 50px rgba(2,6,23,0.6), inset 0 1px 0 rgba(255,255,255,0.06);
    }
    .controls { position: fixed; top: 12px; right: 12px; display: flex; gap: 8px; z-index: 20; }
    .ctrlBtn {
      appearance: none; border: 1px solid rgba(148,163,184,0.28); border-radius: 8px;
      background: #111827; color: var(--text); padding: 6px 10px; cursor: pointer; font-size: 12px;
      box-shadow: 0 6px 16px rgba(2,6,23,0.35), inset 0 1px 0 rgba(255,255,255,0.06);
    }
    .ctrlBtn:hover { background: #1f2937; }

    .piano { display: grid; grid-template-columns: repeat(5, var(--key-size)); grid-auto-rows: var(--key-size); gap: var(--key-gap); justify-content: center; }
    .key {
      appearance: none; border: 1px solid rgba(148,163,184,0.28); border-radius: 50%; touch-action: none; -webkit-tap-highlight-color: transparent;
      background: linear-gradient(180deg, #f8fafc 0%, #e5e7eb 65%, #d1d5db 100%);
      color: #0f172a; width: var(--key-size); height: var(--key-size); padding: 0; cursor: pointer;
      display: flex; align-items: center; justify-content: center; flex: 0 0 auto;
      box-shadow: 0 6px 16px rgba(2,6,23,0.35), inset 0 1px 0 rgba(255,255,255,0.7);
      transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
      position: relative; overflow: hidden;
    }
    .key:hover { background: linear-gradient(180deg, #ffffff 0%, #e2e8f0 70%, #cbd5e1 100%); }
    .key:active { transform: translateY(1px); }
    .key.active { box-shadow: 0 0 0 2px var(--accent) inset, 0 8px 18px rgba(2,6,23,0.4); }
    .label { font-weight: 700; letter-spacing: 0.4px; color: #0f172a; font-size: 12px; }
    .footer { margin-top: 16px; font-size: 12px; color: var(--muted); }
    .kbd { display: inline-block; padding: 2px 6px; border-radius: 6px; background: rgba(148,163,184,0.15); border: 1px solid rgba(148,163,184,0.25); color: var(--text); }

    /* 横屏提示覆盖层 */
    .overlay { position: fixed; inset: 0; background: rgba(2,6,23,0.7); display: none; align-items: center; justify-content: center; z-index: 30; }
    .overlayInner { background: #111827; border: 1px solid rgba(148,163,184,0.28); border-radius: 12px; padding: 16px 18px; width: 92%; max-width: 420px; box-shadow: 0 20px 50px rgba(2,6,23,0.6); }
    .overlayTitle { margin: 0 0 8px; font-size: 18px; }
    .overlayText { margin: 0 0 12px; color: var(--muted); font-size: 14px; }
    .overlayActions { display: flex; gap: 8px; }
    .overlayBtn { appearance: none; border: 1px solid rgba(148,163,184,0.28); border-radius: 8px; background: #1f2937; color: var(--text); padding: 8px 12px; cursor: pointer; font-size: 14px; }
    .overlayBtn.primary { background: #0ea5e9; border-color: #0ea5e9; color: white; }

    /* 简谱识别区（无图片识别，仅文本） */
    .sheet { margin-top: 16px; }
    .sheetGrid { display: grid; grid-template-columns: 1fr; gap: 12px; }
    .sheetItem { background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); border: 1px solid rgba(148,163,184,0.14); border-radius: 14px; padding: 12px; }
    .sheetItem h3 { margin: 0 0 8px; font-size: 16px; }
    .sheetActions { display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
    .sheetBtn { appearance: none; border: 1px solid rgba(148,163,184,0.28); border-radius: 8px; background: #111827; color: var(--text); padding: 6px 10px; cursor: pointer; font-size: 12px; }
    .sheetBtn.primary { background: #0ea5e9; border-color: #0ea5e9; color: white; }
    .sheetInput { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid rgba(148,163,184,0.28); background: #0b1220; color: var(--text); font-size: 13px; }
    .metaRow { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
    .metaRow label { color: var(--muted); font-size: 12px; }
    .metaRow input[type="number"] { width: 90px; padding: 6px; border-radius: 8px; border: 1px solid rgba(148,163,184,0.28); background: #0b1220; color: var(--text); }

    /* 歌曲查询区 */
    .search { margin-top: 16px; }
    .searchGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .resultList { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
    .songTag { appearance: none; border: 1px solid rgba(148,163,184,0.28); border-radius: 16px; padding: 6px 10px; background: #0b1220; color: var(--text); font-size: 12px; cursor: pointer; }

    @media (max-width: 840px) { .searchGrid { grid-template-columns: 1fr; } }
    @media (max-width: 640px) {
      body { padding: 10px; }
      h1 { font-size: 20px; }
      .hint, .footer { font-size: 12px; }
    }
  </style>
</head>
<body>
  <!-- 右上角放大/缩小按钮 -->
  <div class="controls">
    <button id="zoomIn" class="ctrlBtn">放大</button>
    <button id="zoomOut" class="ctrlBtn">缩小</button>
  </div>

  <!-- 横屏提示/授权层 -->
  <div id="orientationOverlay" class="overlay">
    <div class="overlayInner">
      <h2 class="overlayTitle">建议横屏使用</h2>
      <p class="overlayText">为更好的演奏体验，建议将手机横屏。若浏览器支持，我们将尝试自动切换到横屏并解锁音频。</p>
      <div class="overlayActions">
        <button id="tryLandscape" class="overlayBtn primary">尝试横屏并继续</button>
        <button id="continuePortrait" class="overlayBtn">继续竖屏使用</button>
      </div>
    </div>
  </div>

  <div class="container">
    <h1>光遇钢琴小圆点（3×5）</h1>
    <p class="hint">键位：第1组 <span class="kbd">1</span>–<span class="kbd">5</span>，第2组 <span class="kbd">Q</span>–<span class="kbd">T</span>，第3组 <span class="kbd">A</span>–<span class="kbd">G</span></p>
    <div class="card">
      <div id="piano" class="piano"></div>
    </div>
    <p class="footer">提示：支持多点触控，点击按键或按对应键盘键进行播放。</p>

    <!-- 简谱文本与自动演奏（已移除图片识别） -->
    <div class="sheet">
      <div class="sheetGrid">
        <div class="sheetItem">
          <h3>简谱文本与演奏</h3>
          <textarea id="sheetText" rows="6" placeholder="在此输入或粘贴简谱（支持 0-7、.、-、*时值，空格换行分隔）" class="sheetInput"></textarea>
          <div class="metaRow" style="margin-top:8px">
            <label>速度(BPM)：<input id="bpm" type="number" value="90" min="40" max="200" /></label>
            <label>每拍时值(ms)：<span id="beatMs">667</span></label>
          </div>
          <div class="sheetActions">
            <button id="playSheetBtn" class="sheetBtn primary">按简谱自动演奏</button>
            <button id="parseOnlyBtn" class="sheetBtn">仅解析</button>
            <button id="clearTextBtn" class="sheetBtn">清除文本</button>
          </div>
          <div id="parseOutput" style="margin-top:8px; color: var(--muted); font-size: 12px"></div>
        </div>
      </div>
    </div>

    <!-- 歌曲名称查询简谱（本地库） -->
    <div class="search">
      <div class="searchGrid">
        <div class="sheetItem">
          <h3>根据歌曲名称查询简谱（本地库）</h3>
          <input id="songQuery" type="text" class="sheetInput" placeholder="输入歌曲名称，如：小星星、生日快乐" />
          <div class="sheetActions">
            <button id="searchSongBtn" class="sheetBtn primary">搜索</button>
            <button id="resetLibraryBtn" class="sheetBtn">恢复默认歌曲库</button>
          </div>
          <div id="searchResults" class="resultList"></div>
          <div class="sheetActions">
            <button id="playFoundBtn" class="sheetBtn primary">弹奏查询结果</button>
          </div>
        </div>
        <div class="sheetItem">
          <h3>导入/扩展歌曲库（JSON）</h3>
          <input id="songJsonFile" type="file" accept="application/json" class="sheetInput" />
          <div class="sheetActions">
            <button id="loadSongJsonBtn" class="sheetBtn">加载 JSON 文件</button>
          </div>
          <div style="color: var(--muted); font-size: 12px; margin-top: 8px;">
            支持两种格式：<br/>
            1) 数组：[{"title":"小星星","jianpu":"1 1 5 5 6 6 5 - ..."}]<br/>
            2) 映射：{"小星星":"1 1 5 5 6 6 5 - ..."}
          </div>
        </div>
      </div>
    </div>
  </div>

  <script>
    // 音频文件列表（来自 /bgm 目录）
    const audioDir = 'bgm/';
    const audioFilesRaw = [
      'b1.mp3','b2.mp3','b3.mp3','b4.mp3','b5.mp3','b6.mp3','b7.mp3',
      'c1.mp3','c2.mp3','c3.mp3','c4.mp3','c5.mp3','c6.mp3','c7.mp3',
      'd1.mp3'
    ];

    const SLOTS = 15;
    const audioFiles = Array.from({ length: SLOTS }, (_, i) => audioFilesRaw[i % audioFilesRaw.length]);
    const keyMap = ['1','2','3','4','5','Q','W','E','R','T','A','S','D','F','G'];

    const pianoEl = document.getElementById('piano');

    // WebAudio：并发播放更稳定
    const AudioContextClass = window.AudioContext || window.webkitAudioContext;
    const audioCtx = new AudioContextClass();
    const bufferCache = new Map();

    async function loadBuffer(url) {
      if (bufferCache.has(url)) return bufferCache.get(url);
      const res = await fetch(url);
      const arr = await res.arrayBuffer();
      const buf = await audioCtx.decodeAudioData(arr);
      bufferCache.set(url, buf);
      return buf;
    }
    function playBufferAt(url, when) {
      const buf = bufferCache.get(url);
      if (!buf) return;
      const src = audioCtx.createBufferSource();
      src.buffer = buf;
      src.connect(audioCtx.destination);
      src.start(when);
    }
    async function triggerPlay(url) {
      try { if (audioCtx.state === 'suspended') await audioCtx.resume(); } catch {}
      if (!bufferCache.has(url)) { try { await loadBuffer(url); } catch (e) { return; } }
      playBufferAt(url, audioCtx.currentTime);
    }

    function makeKey(index, key, file) {
      const btn = document.createElement('button');
      btn.className = 'key';
      btn.dataset.key = key;
      btn.dataset.src = audioDir + file;
      btn.innerHTML = `<div class="label">${key}</div>`;
      btn.title = file;

      const down = async (e) => { e.preventDefault(); btn.classList.add('active'); triggerPlay(btn.dataset.src); };
      const up = () => { btn.classList.remove('active'); };
      btn.addEventListener('pointerdown', down);
      btn.addEventListener('pointerup', up);
      btn.addEventListener('pointerleave', up);
      btn.addEventListener('pointercancel', up);
      return btn;
    }

    for (let i = 0; i < SLOTS; i++) {
      const key = keyMap[i];
      const file = audioFiles[i];
      pianoEl.appendChild(makeKey(i, key, file));
    }

    // 键盘兼容（Edge/IME/布局），使用 e.code 优先
    function getKeyFromEvent(e) {
      if (e.code && /^Digit[1-5]$/.test(e.code)) return e.code.replace('Digit', '');
      if (e.code && /^Key[QWERTASDFG]$/.test(e.code)) return e.code.replace('Key', '');
      const k = e.key;
      if (typeof k === 'string' && k.length === 1) {
        const up = k.toUpperCase();
        if ('12345QWERTASDFG'.includes(up)) return up;
      }
      return null;
    }

    document.addEventListener('keydown', (e) => {
      if (e.ctrlKey || e.metaKey || e.altKey) return;
      const label = getKeyFromEvent(e);
      if (!label) return;
      const btn = Array.from(document.querySelectorAll('.key')).find(b => b.dataset.key === label);
      if (btn) {
        btn.classList.add('active');
        triggerPlay(btn.dataset.src);
        e.preventDefault();
      }
    });
    document.addEventListener('keyup', (e) => {
      const label = getKeyFromEvent(e);
      if (!label) return;
      const btn = Array.from(document.querySelectorAll('.key')).find(b => b.dataset.key === label);
      if (btn) {
        btn.classList.remove('active');
        e.preventDefault();
      }
    });

    // 缩放控制与自适应
    const zoomInBtn = document.getElementById('zoomIn');
    const zoomOutBtn = document.getElementById('zoomOut');
    let keySize = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--key-size')) || 44;
    const minSize = 28, maxSize = 100, step = 4;
    function applySizes() {
      document.documentElement.style.setProperty('--key-size', keySize + 'px');
      const gap = Math.max(8, Math.round(keySize / 4));
      document.documentElement.style.setProperty('--key-gap', gap + 'px');
    }
    function autoResize() {
      const vw = window.innerWidth;
      const padding = parseInt(getComputedStyle(document.body).paddingLeft) + parseInt(getComputedStyle(document.body).paddingRight);
      const usable = Math.max(320, Math.min(960, vw - padding));
      const gap = Math.max(8, Math.round(keySize / 4));
      let size = Math.floor((usable - gap * 4) / 5);
      size = Math.max(minSize, Math.min(maxSize, size));
      keySize = size; applySizes();
    }
    zoomInBtn.addEventListener('click', () => { keySize = Math.min(maxSize, keySize + step); applySizes(); });
    zoomOutBtn.addEventListener('click', () => { keySize = Math.max(minSize, keySize - step); applySizes(); });
    window.addEventListener('resize', autoResize);
    window.addEventListener('orientationchange', autoResize);
    autoResize();

    // 横屏与音频解锁
    const overlay = document.getElementById('orientationOverlay');
    const tryBtn = document.getElementById('tryLandscape');
    const continueBtn = document.getElementById('continuePortrait');
    function isMobile() { return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent); }
    function shouldShowOverlay() { const portrait = window.matchMedia('(orientation: portrait)').matches; return isMobile() && portrait; }
    async function tryLockLandscape() {
      try {
        if (document.documentElement.requestFullscreen && !document.fullscreenElement) { await document.documentElement.requestFullscreen(); }
        if (screen.orientation && screen.orientation.lock) { await screen.orientation.lock('landscape'); }
      } catch (e) {}
    }
    function hideOverlay() { overlay.style.display = 'none'; }
    function showOverlayIfNeeded() { overlay.style.display = shouldShowOverlay() ? 'flex' : 'none'; }
    tryBtn.addEventListener('click', async () => { try { if (audioCtx.state === 'suspended') await audioCtx.resume(); } catch {} await tryLockLandscape(); hideOverlay(); });
    continueBtn.addEventListener('click', () => { try { if (audioCtx.state === 'suspended') audioCtx.resume(); } catch {} hideOverlay(); });
    window.addEventListener('pointerdown', async () => { try { if (audioCtx.state === 'suspended') await audioCtx.resume(); } catch {} }, { once: true });
    showOverlayIfNeeded();

    // ========= 简谱文本与自动演奏 =========
    const sheetText = document.getElementById('sheetText');
    const parseOutput = document.getElementById('parseOutput');
    const bpmInput = document.getElementById('bpm');
    const beatMsEl = document.getElementById('beatMs');
    const playSheetBtn = document.getElementById('playSheetBtn');
    const parseOnlyBtn = document.getElementById('parseOnlyBtn');
    const clearTextBtn = document.getElementById('clearTextBtn');

    function updateBeatMs() { const bpm = Number(bpmInput.value || 90); beatMsEl.textContent = Math.round(60000 / bpm); }
    bpmInput.addEventListener('input', updateBeatMs); updateBeatMs();

    // 解析简谱：支持 1-7 音符、0 休止；. 为上下加点（前缀低音、后缀高音）；- 为延长一拍；*x 作为时值倍数（可小数）
    function parseJianpu(text) {
      const tokens = text.split(/\s+/).filter(Boolean);
      const seq = [];
      let lastNote = null;
      for (let tok of tokens) {
        // 时值倍数字尾，如 5*1.5、3*2
        let durMul = 1;
        const dm = tok.match(/\*(\d+(?:\.\d+)?)$/);
        if (dm) { durMul = Number(dm[1]); tok = tok.replace(/\*(\d+(?:\.\d+)?)$/, ''); }

        if (/^-+$/.test(tok)) { if (lastNote) lastNote.duration += tok.length; continue; }
        if (tok === '0') { seq.push({ type: 'rest', duration: durMul }); lastNote = null; continue; }
        const base = tok; // 可能包含 . 作高低音标记
        const m = base.match(/^\.*([1-7])\.*$/); if (!m) continue;
        const digit = Number(m[1]);
        const low = base.startsWith('.');
        const high = base.endsWith('.') && !low;
        const octave = low ? 'b' : (high ? 'd' : 'c');
        let url;
        if (octave === 'd') { url = digit === 1 ? `${audioDir}d1.mp3` : `${audioDir}c${digit}.mp3`; }
        else { url = `${audioDir}${octave}${digit}.mp3`; }
        const note = { type: 'note', url, digit, octave, duration: durMul };
        seq.push(note); lastNote = note;
      }
      return seq;
    }

    function formatSeq(seq) { return seq.map(x => x.type === 'rest' ? `休(${x.duration})` : `${x.octave}${x.digit}×${x.duration}`).join(' | '); }
    async function preloadSeq(seq) { const urls = [...new Set(seq.filter(x => x.type === 'note').map(x => x.url))]; await Promise.all(urls.map(u => loadBuffer(u).catch(() => {}))); }
    async function playSequence(seq, bpm) {
      try { if (audioCtx.state === 'suspended') await audioCtx.resume(); } catch {}
      const beat = 60 / bpm; let t = audioCtx.currentTime + 0.12;
      for (const item of seq) { if (item.type === 'rest') { t += item.duration * beat; } else { playBufferAt(item.url, t); t += item.duration * beat; } }
    }

    parseOnlyBtn.addEventListener('click', () => { const seq = parseJianpu(sheetText.value || ''); parseOutput.textContent = '解析结果：' + formatSeq(seq); });
    clearTextBtn.addEventListener('click', () => { sheetText.value = ''; parseOutput.textContent = ''; });
    playSheetBtn.addEventListener('click', async () => {
      const text = sheetText.value || ''; if (!text.trim()) { parseOutput.textContent = '请先输入或粘贴简谱文本。'; return; }
      const bpm = Math.max(40, Math.min(200, Number(bpmInput.value || 90)));
      const seq = parseJianpu(text);
      parseOutput.textContent = '解析结果：' + formatSeq(seq) + `\nBPM: ${bpm}`; await preloadSeq(seq); await playSequence(seq, bpm);
    });

    // ========= 歌曲查询简谱（本地库） =========
    const songQuery = document.getElementById('songQuery');
    const searchSongBtn = document.getElementById('searchSongBtn');
    const searchResults = document.getElementById('searchResults');
    const playFoundBtn = document.getElementById('playFoundBtn');
    const songJsonFile = document.getElementById('songJsonFile');
    const loadSongJsonBtn = document.getElementById('loadSongJsonBtn');
    const resetLibraryBtn = document.getElementById('resetLibraryBtn');

    let songLibrary = [
      { title: '小星星', jianpu: '1 1 5 5 6 6 5*2 4 4 3 3 2 2 1*2' },
      { title: '生日快乐', jianpu: '5 5 6 5 1*2 7 5 5 6 5 2*2 1 5 5 5*1.5 3*0.5 1 7 6*2 4*1.5 4*0.5 3 1 2 1*2' },
      { title: '两只老虎', jianpu: '1 2 3 1*2 1 2 3 1*2 3 4 5*2 3 4 5*2 5 6 5 4 3 1*2 5 6 5 4 3 1*2' }
    ];
    const defaultLibrary = JSON.parse(JSON.stringify(songLibrary));
    let lastResults = [];
    let selectedIdx = -1;

    function normalize(s) { return s.toLowerCase(); }
    function searchSongs(q) {
      q = normalize(q.trim()); if (!q) return [];
      return songLibrary.filter(s => normalize(s.title).includes(q));
    }
    function renderResults(items) {
      lastResults = items; selectedIdx = items.length ? 0 : -1;
      searchResults.innerHTML = items.length ? items.map((s,i) => `<button class=\"songTag\" data-idx=\"${i}\">${s.title}</button>`).join('') : '<span style="color: var(--muted); font-size:12px">未找到匹配歌曲</span>';
      Array.from(searchResults.querySelectorAll('.songTag')).forEach(btn => {
        btn.addEventListener('click', () => { selectedIdx = Number(btn.dataset.idx); const item = lastResults[selectedIdx]; sheetText.value = item.jianpu; parseOutput.textContent = `已选择：${item.title}`; });
      });
      if (items.length) { const item = items[0]; sheetText.value = item.jianpu; parseOutput.textContent = `已选择：${item.title}`; }
    }

    searchSongBtn.addEventListener('click', () => { renderResults(searchSongs(songQuery.value)); });
    songQuery.addEventListener('keydown', (e) => { if (e.key === 'Enter') { renderResults(searchSongs(songQuery.value)); } });

    playFoundBtn.addEventListener('click', async () => {
      if (selectedIdx < 0 || !lastResults.length) { parseOutput.textContent = '请先搜索并选择歌曲。'; return; }
      const item = lastResults[selectedIdx]; const bpm = Math.max(40, Math.min(200, Number(bpmInput.value || 90)));
      const seq = parseJianpu(item.jianpu);
      parseOutput.textContent = `歌曲：${item.title}\n解析结果：` + formatSeq(seq) + `\nBPM: ${bpm}`;
      await preloadSeq(seq); await playSequence(seq, bpm);
    });

    loadSongJsonBtn.addEventListener('click', async () => {
      const f = songJsonFile.files && songJsonFile.files[0]; if (!f) { parseOutput.textContent = '请选择 JSON 文件。'; return; }
      try {
        const text = await f.text(); const data = JSON.parse(text);
        let imported = [];
        if (Array.isArray(data)) { imported = data.filter(x => x && x.title && x.jianpu).map(x => ({ title: String(x.title), jianpu: String(x.jianpu) })); }
        else if (data && typeof data === 'object') { imported = Object.keys(data).map(k => ({ title: String(k), jianpu: String(data[k]) })); }
        if (!imported.length) { parseOutput.textContent = 'JSON 格式不正确或无有效数据。'; return; }
        songLibrary = imported; parseOutput.textContent = `已加载歌曲数：${songLibrary.length}`; renderResults(searchSongs(songQuery.value || ''));
      } catch (e) { parseOutput.textContent = '加载失败：' + (e && e.message ? e.message : e); }
    });
    resetLibraryBtn.addEventListener('click', () => { songLibrary = JSON.parse(JSON.stringify(defaultLibrary)); parseOutput.textContent = '已恢复默认歌曲库。'; renderResults(searchSongs(songQuery.value || '')); });
  </script>
</body>
</html>